昨天我們實作 QRCode 掃描並取得資料,今天我們將繼續這個功能,實作掃描完成後,將購物清單顯示出來並讓使用者可以進行編輯、刪除等操作。
今天的實作主要目標是:
先來建立一個新的 Model - ScannedItem,用來儲存發票的明細。
struct ScannedItem: Identifiable, Equatable {
var id = UUID()
var name: String
var quantity: Int
var price: Double
}
這裡宣告的變數做了以下四點更新:
fullScreenCover
方式開啟相機,新增 isShowScanner 控制相機 UI 開關。@State private var isShowScanner = false
@State private var scanResult: [ScannedItem] = []
@State private var isNavigatingToShoppingList = false
接著,我們修改右上角的相機按鈕,使用 fullScreenCover
開啟掃描畫面,並在掃描完成後將使用者導向購物清單頁面。這裡使用 onChange
,當 scanResult 更新時,畫面就會跳轉到清單頁。
.navigationBarItems(trailing: Button(action: {
isShowScanner = true // 開啟相機視圖
}) {
Image(systemName: "camera")
.font(.title2)
})
.fullScreenCover(isPresented: $isShowScanner) {
// 使用 fullScreenCover 開啟相機掃描畫面
QRScannerView(result: $scanResult, isPresented: $isShowScanner)
}
.onChange(of: scanResult) { newResult in
if !newResult.isEmpty {
// 當掃描完成後,處理掃描結果並跳轉到購物清單頁
isNavigatingToShoppingList = true
}
}
.background(
// 隱藏的 NavigationLink,用於在掃描結果更新後進行跳轉
NavigationLink(destination: ShoppingListView(viewModel: ShoppingListViewModel(shoppingItems: scanResult)), isActive: $isNavigatingToShoppingList) {
EmptyView()
}
)
我們來更新 QRScannerView,實作掃描完成後處理購物資料。當掃描完成後將資料存入 result,將掃描結果傳遞到 AddItemView,並且透過 isPresented 控制掃描頁面的開啟與關閉。
struct QRScannerView: UIViewControllerRepresentable {
@Binding var result: [ScannedItem]
@Binding var isPresented: Bool
func makeUIViewController(context: Context) -> QRScannerController {
let scannerController = QRScannerController()
scannerController.onQRCodeScanned = { scannedItems in
if scannedItems.isEmpty {
// 如果沒有商品明細,保持相機頁面
isPresented = false
} else {
// 如果掃描成功,將結果返回並關閉相機頁面
result = scannedItems
isPresented = false
}
}
return scannerController
}
func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {
// 無需更新
}
func makeCoordinator() -> Coordinator {
Coordinator($result, $isPresented)
}
}
class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
@Binding var scanResult: [ScannedItem]
@Binding var isPresented: Bool
init(_ scanResult: Binding<[ScannedItem]>, _ isPresented: Binding<Bool>) {
self._scanResult = scanResult
self._isPresented = isPresented
}
}
既然開啟相機頁面已經不是使用 NavigationLink
,那我們需要建立一個按鈕方便使用者可以關閉相機。
override func viewDidLoad() {
super.viewDidLoad()
checkCameraAuthorization()
let closeButton = UIButton(frame: CGRect(x: view.bounds.width - 60, y: 40, width: 40, height: 40))
closeButton.setTitle("X", for: .normal)
closeButton.setTitleColor(.white, for: .normal)
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.7)
closeButton.layer.cornerRadius = 20
closeButton.addTarget(self, action: #selector(closeScanner), for: .touchUpInside)
view.addSubview(closeButton)
}
@objc func closeScanner() {
self.dismiss(animated: true, completion: nil) // 關閉相機頁面
}
當掃描成功後,將結果解析並儲存在 ScannedItem 中,然後傳回給 AddItemView。至於要怎麼解析發票的 QRCode 呢?我們可以上網找到 電子發票證明聯一維及二維條碼規格說明 這份文件。
參考資料:電子發票證明聯一維及二維條碼規格說明
我們來更新 metadataOutput,需要讓它呼叫解析 QRCode 的程式,並且實作錯誤提示。
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, metadataObject.type == .qr {
if let stringValue = metadataObject.stringValue {
// 解析 QRCode
let scannedItems = parseQRCode(stringValue)
if scannedItems.isEmpty {
// 如果無法解析或沒有商品明細,顯示錯誤提示
showAlertAndRestartCamera()
} else {
// 傳遞解析結果並關閉相機
onQRCodeScanned?(scannedItems)
}
}
} else {
// 如果沒有掃描到有效的 QRCode,顯示錯誤提示
showAlertAndRestartCamera()
}
}
// 顯示錯誤提示並重啟相機
func showAlertAndRestartCamera() {
let alert = UIAlertController(title: "無效的 QRCode", message: "無法解析商品明細,請重試。", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "確定", style: .default) { [weak self] _ in
self?.restartCameraSession()
})
present(alert, animated: true)
}
// 重啟相機
func restartCameraSession() {
captureSession.stopRunning()
DispatchQueue.global(qos: .background).async {
self.captureSession.startRunning()
}
}
// 停止會話
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
captureSession.stopRunning()
}
接著就開始實作解析 QRCode 的部分,根據文件所述,左邊 QRCode 和 右邊 QRCode 格式不相同。左邊會在第96個字才開始是商品明細,前面大多描述發票號碼、開立日期、銷售額、總計額等等資訊。而右邊 QRCode 會直接以 「**」作為開頭,用來區分左邊和右邊的QRCode。
// 解析 QRCode 的函數
func parseQRCode(_ qrCode: String) -> [ScannedItem] {
var items: [ScannedItem] = []
// 檢查 QRCode 是否為右邊的格式(以 ** 開頭)
if qrCode.hasPrefix("**") {
let rightPart = String(qrCode.dropFirst(2))
items = parseProductDetails(from: rightPart)
} else if qrCode.count > 95 {
// 左邊的 QRCode 從第 95 個字元開始解析商品資訊
let leftPart = String(qrCode.dropFirst(95))
// 取得編碼參數 (第 3 個欄位)
items = parseProductDetails(from: leftPart)
}
return items
}
QRCode 的資訊會以「:」作為區隔。根據上方的範例資訊,我們要取得的是「乾電池:1:105:」或「口罩:1:210:牛奶:1:25」。它的格式是「商品名稱:數量:單價」,三個資訊為一組。因此我們使用「:」將資訊切割分別放入相對應的欄位。
// 解析商品明細的輔助函數
func parseProductDetails(from details: String) -> [ScannedItem] {
var items: [ScannedItem] = []
let components = details.components(separatedBy: ":")
// 每 3 個為一組:品名:數量:單價
for i in stride(from: 0, to: components.count, by: 3) {
if i + 2 < components.count {
let name = components[i] // 商品名稱
if let count = Int(components[i + 1]), // 數量
let price = Double(components[i + 2]) { // 單價
let item = ScannedItem(name: name, quantity: count, price: price)
items.append(item)
}
}
}
return items
}
如此一來,就可以取到我們想要的商品資訊了。
建立 ShoppingListView 與 ShoppingListViewModel 來顯示我們取得的商品清單。
class ShoppingListViewModel: ObservableObject {
@Published var shoppingItems: [ScannedItem] = []
init(shoppingItems: [ScannedItem] = []) {
self.shoppingItems = shoppingItems
}
}
struct ShoppingListView: View {
@ObservedObject var viewModel: ShoppingListViewModel
var body: some View {
VStack {
List {
ForEach(viewModel.shoppingItems) { item in
HStack {
Text(item.name)
Spacer()
Text("\(item.quantity) x \(String(format: "%.2f", item.price))")
}
}
.onDelete(perform: deleteItem)
}
Button(action: {
viewModel.addItemsToInventory()
}) {
Text("新增物品")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding()
}
.navigationBarTitle("購物清單", displayMode: .inline)
}
private func deleteItem(at offsets: IndexSet) {
viewModel.shoppingItems.remove(atOffsets: offsets)
}
}
#Preview {
ShoppingListView(viewModel: ShoppingListViewModel())
}
今天我們實作掃描 QRCode 並解析消費明細的功能。成功的抓取到 QRCode 並取得消費明細,並且讓明細可以傳遞到下一個列表的頁面。只不過在電子發票整合服務平台的文件有說到,QRCode 其實有三種格式:Big5、UTF-8 和 base64。我們今天只用 UTF-8 解析 QRCode。這部分我們明天再來解決吧,今天就先到這裡,明天見囉~